add basic JavaScriptAgent

Andrew Cantino 10 jaren geleden
bovenliggende
commit
e8260ee61e
5 gewijzigde bestanden met toevoegingen van 373 en 111 verwijderingen
  1. 2 1
      app/models/agent.rb
  2. 0 108
      app/models/agents/code_agent.rb
  3. 169 0
      app/models/agents/java_script_agent.rb
  4. 2 2
      spec/fixtures/events.yml
  5. 200 0
      spec/models/agents/java_script_agent_spec.rb

+ 2 - 1
app/models/agent.rb

@@ -16,7 +16,7 @@ class Agent < ActiveRecord::Base
16 16
   load_types_in "Agents"
17 17
 
18 18
   SCHEDULES = %w[every_2m every_5m every_10m every_30m every_1h every_2h every_5h every_12h every_1d every_2d every_7d
19
-                 midnight 1am 2am 3am 4am 5am 6am 7am 8am 9am 10am 11am noon 1pm 2pm 3pm 4pm 5pm 6pm 7pm 8pm 9pm 10pm 11pm]
19
+                 midnight 1am 2am 3am 4am 5am 6am 7am 8am 9am 10am 11am noon 1pm 2pm 3pm 4pm 5pm 6pm 7pm 8pm 9pm 10pm 11pm never]
20 20
 
21 21
   EVENT_RETENTION_SCHEDULES = [["Forever", 0], ["1 day", 1], *([2, 3, 4, 5, 7, 14, 21, 30, 45, 90, 180, 365].map {|n| ["#{n} days", n] })]
22 22
 
@@ -296,6 +296,7 @@ class Agent < ActiveRecord::Base
296 296
     # Given a schedule name, run `check` via `bulk_check` on all Agents with that schedule.
297 297
     # This is called by bin/schedule.rb for each schedule in `SCHEDULES`.
298 298
     def run_schedule(schedule)
299
+      return if schedule == 'never'
299 300
       types = where(:schedule => schedule).group(:type).pluck(:type)
300 301
       types.each do |type|
301 302
         type.constantize.bulk_check(schedule)

+ 0 - 108
app/models/agents/code_agent.rb

@@ -1,108 +0,0 @@
1
-require 'date'
2
-require 'cgi'
3
-module Agents
4
-  class CodeAgent < Agent
5
-    #cannot_receive_events!
6
-    #cannot_be_scheduled!
7
-    description <<-MD
8
-      Here is an agent that gives you the ability to specify your own code. We have already provided you
9
-      a javascript object that has read and write access to this agent's memory, and read access to events, options and the attributes of the agent.
10
-      We also provide you with a method to create events on the server.
11
-      You will be provided with an instance of the Agent object in javascript, with access to the above data.
12
-      You can create events based on your own logic.
13
-      Specifically, you have the following class, lets say, present is a string "js_code".
14
-
15
-          function Agent(e, o, agent){
16
-          this.events = JSON.parse(e);
17
-          this.options = JSON.parse(o);
18
-          this.agent = JSON.parse(agent);
19
-          }
20
-          Agent.prototype.check = function(){
21
-            // Implement me
22
-           }
23
-          Agent.prototype.receive = function(){
24
-            // Implement me
25
-          }
26
-      You need to provide the code for the Agent::check and Agent::receive function. You code will override any methods already present in the Agent if it has to, and you can use other methods as well with access to the agent properties. You need to at least provide the implementation of Agent.prototype.check and Agent.prototype.receive so that it can be called periodically, or it can execute when an event happens.
27
-
28
-      We will yield control to your implementation in the following way:
29
-
30
-          context.eval(js_code); //this is the code that declares the class Agent, and provides a global create_event method.
31
-          context.eval("a = new Agent(events, options, agent)")
32
-          context.eval(options['code'])
33
-          If you agent fires periodically {
34
-          context.eval("a.check();")
35
-          } else if your agent responds when an event happens {
36
-            context.eval("a.receive();")
37
-          }
38
-
39
-      If your agent responds to events it receive, you need to implement the receive() method, and if you agent fires periodically, you need to implement check(). If your agent does both, please implement both!
40
-
41
-    MD
42
-    def example_js
43
-    <<-H
44
-    function Agent(e, o, agent){
45
-    this.events = JSON.parse(e);
46
-    this.options = JSON.parse(o);
47
-    this.agent = JSON.parse(agent);
48
-    }
49
-    Agent.prototype.memory = function(key,value){
50
-      if (typeof(key) != "undefined" && typeof(value) != "undefined") {
51
-        var mem = JSON.parse(access_memory(JSON.stringify(key), JSON.stringify(value)));
52
-        return JSON.stringify(mem);
53
-      } else {
54
-        var mem = JSON.parse(access_memory());
55
-        return JSON.stringify(mem);
56
-      }
57
-    }
58
-    Agent.prototype.check = function(){
59
-    }
60
-    Agent.prototype.receive = function(){
61
-    }
62
-    H
63
-    end
64
-
65
-    def working?
66
-      return false if recent_error_logs?
67
-      if options['expected_update_period_in_days'].present?
68
-        return false unless event_created_within?(options['expected_update_period_in_days'])
69
-      end
70
-      if options['expected_receive_period_in_days'].present?
71
-        return false unless last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago
72
-      end
73
-      true
74
-    end
75
-
76
-    def execute_js(incoming_events, js_function = "check")
77
-      js_function = (js_function == "check" ? "check" : "receive")
78
-      context = V8::Context.new
79
-      context.eval(example_js)
80
-      context["create_event"] = lambda {|x,y| puts x; puts y; create_event payload: JSON.parse(y)}
81
-      context["access_memory"] = lambda {|a, x, y| x && y ? (memory[x] = y; memory.to_json) : memory.to_json }
82
-
83
-      context.eval(options['code']) # should override the run function.
84
-      a, e, o = [self.attributes.to_json, incoming_events.to_json, self.options.to_json]
85
-      string = "a = new Agent('#{e}','#{o}','#{a}');"
86
-      context.eval(string)
87
-      runner = "a.#{js_function}();"
88
-      context.eval(runner)
89
-    end
90
-
91
-    def check
92
-      execute_js("")
93
-    end
94
-
95
-    def receive(incoming_events)
96
-      execute_js(incoming_events)
97
-    end
98
-
99
-    def default_options
100
-      js_code = "Agent.prototype.check = function(){ var pd = JSON.stringify({memory: this.memory(), events: this.events, options: this.options});create_event(pd); };Agent.prototype.receive = function(){ var pd = JSON.stringify({memory: this.memory(), events: this.events, options: this.options});create_event(pd); }"
101
-      {
102
-        "code" => js_code,
103
-        'expected_receive_period_in_days' => "2",
104
-        'expected_update_period_in_days' => "2"
105
-      }
106
-    end
107
-  end
108
-end

+ 169 - 0
app/models/agents/java_script_agent.rb

@@ -0,0 +1,169 @@
1
+require 'date'
2
+require 'cgi'
3
+
4
+module Agents
5
+  class JavaScriptAgent < Agent
6
+    default_schedule "never"
7
+
8
+    description <<-MD
9
+      This Agent allows you to write code in JavaScript that can create and receive events.  If other Agents aren't meeting your needs, try this one!
10
+
11
+      At the moment, all code should be written in the `code` option.  In the future, a full editor will be provided.
12
+
13
+      You can implement `Agent.check` and `Agent.receive` as you see fit.  The following methods will be available on Agent in the JavaScript environment:
14
+
15
+      * `this.createEvent(payload)`
16
+      * `this.incomingEvents()`
17
+      * `this.memory()`
18
+      * `this.memory(key)`
19
+      * `this.memory(keyToSet, valueToSet)`
20
+      * `this.options()`
21
+      * `this.options(key)`
22
+      * `this.log(message)`
23
+      * `this.error(message)`
24
+
25
+    MD
26
+
27
+    def validate_options
28
+      errors.add(:base, "The 'code' option is required") unless options['code'].present?
29
+    end
30
+
31
+    def working?
32
+      return false if recent_error_logs?
33
+
34
+      if options['expected_update_period_in_days'].present?
35
+        return false unless event_created_within?(options['expected_update_period_in_days'])
36
+      end
37
+
38
+      if options['expected_receive_period_in_days'].present?
39
+        return false unless last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago
40
+      end
41
+
42
+      true
43
+    end
44
+
45
+    def check
46
+      log_errors do
47
+        execute_js("check")
48
+      end
49
+    end
50
+
51
+    def receive(incoming_events)
52
+      log_errors do
53
+        execute_js("receive", incoming_events)
54
+      end
55
+    end
56
+
57
+    def default_options
58
+      js_code = <<-JS
59
+        Agent.check = function() {
60
+          if (this.options('make_event')) {
61
+            this.createEvent({ 'message': 'I made an event!' });
62
+            var callCount = this.memory('callCount') || 0;
63
+            this.memory('callCount', callCount + 1);
64
+          }
65
+        };
66
+        
67
+        Agent.receive = function() {
68
+          var events = this.incomingEvents();
69
+          for(var i = 0; i < events.length; i++) {
70
+            this.createEvent({ 'message': 'I got an event!', 'event_was': events[i].payload });
71
+          }
72
+        }
73
+      JS
74
+
75
+      {
76
+        "code" => js_code.gsub(/[\n\r\t]/, '').strip,
77
+        'expected_receive_period_in_days' => "2",
78
+        'expected_update_period_in_days' => "2"
79
+      }
80
+    end
81
+
82
+    private
83
+
84
+    def execute_js(js_function, incoming_events = [])
85
+      js_function = js_function == "check" ? "check" : "receive"
86
+      context = V8::Context.new
87
+      context.eval(setup_javascript)
88
+
89
+      context["doCreateEvent"] = lambda { |a, y| create_event(payload: clean_nans(JSON.parse(y))).payload.to_json }
90
+      context["getIncomingEvents"] = lambda { |a| incoming_events.to_json }
91
+      context["getOptions"] = lambda { |a, x| options.to_json }
92
+      context["doLog"] = lambda { |a, x| log x }
93
+      context["doError"] = lambda { |a, x| error x }
94
+      context["getMemory"] = lambda do |a, x, y|
95
+        if x && y
96
+          memory[x] = clean_nans(y)
97
+        else
98
+          memory.to_json
99
+        end
100
+      end
101
+
102
+      context.eval(options['code'])
103
+      context.eval("Agent.#{js_function}();")
104
+    end
105
+
106
+    def setup_javascript
107
+      <<-JS
108
+        function Agent() {};
109
+
110
+        Agent.createEvent = function(opts) {
111
+          return JSON.parse(doCreateEvent(JSON.stringify(opts)));
112
+        }
113
+
114
+        Agent.incomingEvents = function() {
115
+          return JSON.parse(getIncomingEvents());
116
+        }
117
+
118
+        Agent.memory = function(key, value) {
119
+          if (typeof(key) !== "undefined" && typeof(value) !== "undefined") {
120
+            getMemory(key, value);
121
+          } else if (typeof(key) !== "undefined") {
122
+            return JSON.parse(getMemory())[key];
123
+          } else {
124
+            return JSON.parse(getMemory());
125
+          }
126
+        }
127
+
128
+        Agent.options = function(key) {
129
+          if (typeof(key) !== "undefined") {
130
+            return JSON.parse(getOptions())[key];
131
+          } else {
132
+            return JSON.parse(getOptions());
133
+          }
134
+        }
135
+
136
+        Agent.log = function(message) {
137
+          doLog(message);
138
+        }
139
+
140
+        Agent.error = function(message) {
141
+          doError(message);
142
+        }
143
+
144
+        Agent.check = function(){};
145
+        Agent.receive = function(){};
146
+      JS
147
+    end
148
+
149
+    def log_errors
150
+      begin
151
+        yield
152
+      rescue V8::Error => e
153
+        error "JavaScript error: #{e.message}"
154
+      end
155
+    end
156
+
157
+    def clean_nans(input)
158
+      if input.is_a?(Array)
159
+        input.map {|v| clean_nans(v) }
160
+      elsif input.is_a?(Hash)
161
+        input.inject({}) { |m, (k, v)| m[k] = clean_nans(v); m }
162
+      elsif input.is_a?(Float) && input.nan?
163
+        'NaN'
164
+      else
165
+        input
166
+      end
167
+    end
168
+  end
169
+end

+ 2 - 2
spec/fixtures/events.yml

@@ -1,9 +1,9 @@
1 1
 bob_website_agent_event:
2 2
   user: bob
3 3
   agent: bob_website_agent
4
-  payload: <%= [{ :title => "foo", :url => "http://foo.com" }].to_json.inspect %>
4
+  payload: <%= { :title => "foo", :url => "http://foo.com" }.to_json.inspect %>
5 5
 
6 6
 jane_website_agent_event:
7 7
   user: jane
8 8
   agent: jane_website_agent
9
-  payload: <%= [{ :title => "foo", :url => "http://foo.com" }].to_json.inspect %>
9
+  payload: <%= { :title => "foo", :url => "http://foo.com" }.to_json.inspect %>

+ 200 - 0
spec/models/agents/java_script_agent_spec.rb

@@ -0,0 +1,200 @@
1
+require 'spec_helper'
2
+
3
+describe Agents::JavaScriptAgent do
4
+  before do
5
+    @valid_params = {
6
+      :name => "somename",
7
+      :options => {
8
+        :code => "Agent.check = function() { this.createEvent({ 'message': 'hi' }); };",
9
+      }
10
+    }
11
+
12
+    @agent = Agents::JavaScriptAgent.new(@valid_params)
13
+    @agent.user = users(:jane)
14
+    @agent.save!
15
+  end
16
+
17
+  describe "validations" do
18
+    it "requires 'code'" do
19
+      @agent.should be_valid
20
+      @agent.options['code'] = ''
21
+      @agent.should_not be_valid
22
+      @agent.options.delete('code')
23
+      @agent.should_not be_valid
24
+    end
25
+  end
26
+
27
+  describe "#working?" do
28
+    describe "when expected_update_period_in_days is set" do
29
+      it "returns false when more than expected_update_period_in_days have passed since the last event creation" do
30
+        @agent.options['expected_update_period_in_days'] = 1
31
+        @agent.save!
32
+        @agent.should_not be_working
33
+        @agent.check
34
+        @agent.reload.should be_working
35
+        three_days_from_now = 3.days.from_now
36
+        stub(Time).now { three_days_from_now }
37
+        @agent.should_not be_working
38
+      end
39
+    end
40
+
41
+    describe "when expected_receive_period_in_days is set" do
42
+      it "returns false when more than expected_receive_period_in_days have passed since the last event was received" do
43
+        @agent.options['expected_receive_period_in_days'] = 1
44
+        @agent.save!
45
+        @agent.should_not be_working
46
+        Agents::JavaScriptAgent.async_receive @agent.id, [events(:bob_website_agent_event).id]
47
+        @agent.reload.should be_working
48
+        two_days_from_now = 2.days.from_now
49
+        stub(Time).now { two_days_from_now }
50
+        @agent.reload.should_not be_working
51
+      end
52
+    end
53
+  end
54
+
55
+  describe "executing code" do
56
+    it "works by default" do
57
+      @agent.options = @agent.default_options
58
+      @agent.options['make_event'] = true;
59
+      @agent.save!
60
+
61
+      lambda {
62
+        lambda {
63
+          @agent.receive([events(:bob_website_agent_event)])
64
+          @agent.check
65
+        }.should_not change { AgentLog.count }
66
+      }.should change { Event.count }.by(2)
67
+    end
68
+
69
+    describe "error handling" do
70
+      it "should log an error when V8 has issues" do
71
+        @agent.options['code'] = 'syntax error!'
72
+        @agent.save!
73
+        lambda {
74
+          lambda {
75
+            @agent.check
76
+          }.should_not raise_error
77
+        }.should change { AgentLog.count }.by(1)
78
+        AgentLog.last.message.should =~ /Unexpected identifier/
79
+        AgentLog.last.level.should == 4
80
+      end
81
+
82
+      it "should log an error when JavaScript throws" do
83
+        @agent.options['code'] = 'Agent.check = function() { throw "oh no"; };'
84
+        @agent.save!
85
+        lambda {
86
+          lambda {
87
+            @agent.check
88
+          }.should_not raise_error
89
+        }.should change { AgentLog.count }.by(1)
90
+        AgentLog.last.message.should =~ /oh no/
91
+        AgentLog.last.level.should == 4
92
+      end
93
+
94
+      it "won't store NaNs" do
95
+        @agent.options['code'] = 'Agent.check = function() { this.memory("foo", NaN); };'
96
+        @agent.save!
97
+        @agent.check
98
+        @agent.memory['foo'].should == 'NaN' # string
99
+        @agent.save!
100
+        lambda { @agent.reload.memory }.should_not raise_error
101
+      end
102
+    end
103
+
104
+    describe "creating events" do
105
+      it "creates events with this.createEvent in the JavaScript environment" do
106
+        @agent.options['code'] = 'Agent.check = function() { this.createEvent({ message: "This is an event!", stuff: { foo: 5 } }); };'
107
+        @agent.save!
108
+        lambda {
109
+          lambda {
110
+            @agent.check
111
+          }.should_not change { AgentLog.count }
112
+        }.should change { Event.count }.by(1)
113
+        created_event = @agent.events.last
114
+        created_event.payload.should == { 'message' => "This is an event!", 'stuff' => { 'foo' => 5 } }
115
+      end
116
+    end
117
+
118
+    describe "logging" do
119
+      it "can output AgentLogs with this.log and this.error in the JavaScript environment" do
120
+        @agent.options['code'] = 'Agent.check = function() { this.log("woah"); this.error("WOAH!"); };'
121
+        @agent.save!
122
+        lambda {
123
+          lambda {
124
+            @agent.check
125
+          }.should_not raise_error
126
+        }.should change { AgentLog.count }.by(2)
127
+
128
+        log1, log2 = AgentLog.last(2)
129
+
130
+        log1.message.should == "woah"
131
+        log1.level.should == 3
132
+        log2.message.should == "WOAH!"
133
+        log2.level.should == 4
134
+      end
135
+    end
136
+
137
+    describe "getting incoming events" do
138
+      it "can access incoming events in the JavaScript enviroment via this.incomingEvents" do
139
+        event = Event.new
140
+        event.agent = agents(:bob_rain_notifier_agent)
141
+        event.payload = { :data => "Something you should know about" }
142
+        event.save!
143
+        event.reload
144
+
145
+        @agent.options['code'] = <<-JS
146
+          Agent.receive = function() {
147
+            var events = this.incomingEvents();
148
+            for(var i = 0; i < events.length; i++) {
149
+              this.createEvent({ 'message': 'I got an event!', 'event_was': events[i].payload });
150
+            }
151
+          }
152
+        JS
153
+
154
+        @agent.save!
155
+        lambda {
156
+          lambda {
157
+            @agent.receive([events(:bob_website_agent_event), event])
158
+          }.should_not change { AgentLog.count }
159
+        }.should change { Event.count }.by(2)
160
+        created_event = @agent.events.first
161
+        created_event.payload.should == { 'message' => "I got an event!", 'event_was' => { 'data' => "Something you should know about" } }
162
+      end
163
+    end
164
+
165
+    describe "getting and setting memory, getting options" do
166
+      it "can access options via this.options and work with memory via this.memory" do
167
+        @agent.options['code'] = <<-JS
168
+          Agent.check = function() {
169
+            if (this.options('make_event')) {
170
+              var callCount = this.memory('callCount') || 0;
171
+              this.memory('callCount', callCount + 1);
172
+            }
173
+          };
174
+        JS
175
+
176
+        @agent.save!
177
+
178
+        lambda {
179
+          lambda {
180
+
181
+            @agent.check
182
+            @agent.memory['callCount'].should_not be_present
183
+
184
+            @agent.options['make_event'] = true
185
+            @agent.check
186
+            @agent.memory['callCount'].should == 1
187
+
188
+            @agent.check
189
+            @agent.memory['callCount'].should == 2
190
+
191
+            @agent.memory['callCount'] = 20
192
+            @agent.check
193
+            @agent.memory['callCount'].should == 21
194
+
195
+          }.should_not change { AgentLog.count }
196
+        }.should_not change { Event.count }
197
+      end
198
+    end
199
+  end
200
+end